Virtual DOM
contents
Virtual DOM은 흔히 React 성능의 "마법"이라고 불리지만, 실제로는 UI에 적용된 "더티 체킹(dirty checking)" 또는 "이중 버퍼링(double buffering)" 이라는 매우 논리적인 소프트웨어 엔지니어링 패턴입니다.
이것이 무엇인지, 왜 존재하는지, 그리고 구체적으로 어떤 알고리즘으로 작동하는지 알아보겠습니다.
1. 문제점: 실제 DOM은 "비쌉니다"
Virtual DOM을 이해하려면 먼저 왜 일반적인 브라우저 DOM(Document Object Model)을 그냥 사용하면 안 되는지 알아야 합니다.
DOM은 자바스크립트가 HTML과 상호작용할 수 있게 해주는 인터페이스입니다. 하지만 DOM은 현대 앱에서 볼 수 있는 동적이고 빈번한 업데이트(예: 검색창 타이핑, 실시간 채팅 피드 등)에 최적화되어 있지 않습니다.
- 리플로우(Reflow) & 리페인트(Repaint): 요소 하나를 변경하면(예:
<div>의 너비를50%에서100%로 변경), 브라우저는 페이지 전체의 기하학적 구조(레이아웃)를 다시 계산(리플로우)하고 픽셀을 다시 그려야(리페인트) 합니다. 이는 연산 비용이 매우 큽니다. - 순차적 업데이트: 만약 DOM을 연속으로 10번 업데이트하면, 브라우저는 레이아웃을 10번 다시 계산하려고 시도할 수 있습니다.
Virtual DOM은 실제 DOM과의 상호작용을 최소화함으로써 이 문제를 해결합니다.
2. Virtual DOM이란 무엇인가?
Virtual DOM(VDOM)은 UI의 가상 표현을 메모리에 유지하는 프로그래밍 개념입니다.
기술적으로 말하면, 이것은 실제 DOM의 구조를 흉내 낸 경량 자바스크립트 객체(JavaScript Object)일 뿐입니다.
실제 DOM 노드:
Hello
(이것은 수백 개의 속성과 메서드를 가진 무거운 브라우저 객체입니다.)
Virtual DOM 노드 (자바스크립트 객체):
const vNode = {
type: 'div',
props: {
id: 'container',
className: 'active',
children: [
{
type: 'h1',
props: { children: 'Hello' }
}
]
}
};
(이것은 평범한 JS 객체입니다. 10만 개를 만들어도 매우 빠르고 메모리를 거의 차지하지 않습니다.)
3. 처리 과정: React가 업데이트하는 방식
React에서 setState를 사용하면 다음과 같은 과정이 일어납니다. 크게 렌더 단계(Render Phase) 와 커밋 단계(Commit Phase) 로 나뉩니다.
1단계: 렌더 단계 (계산)
이 단계는 React가 무엇이 바뀌어야 하는지 알아내는 단계입니다. 순수 자바스크립트로 이루어지며 아직 브라우저 DOM을 건드리지 않습니다.
- 스냅샷 (Snapshot): React는 변경된 State를 기반으로 UI가 어떻게 보여야 하는지 나타내는 새로운 Virtual DOM 트리를 생성합니다.
- 비교 (Diffing / Reconciliation): React는 이 새로운 가상 트리를 이전 가상 트리(이전 렌더링 결과)와 비교합니다.
- 변경 사항 식별: 실제 DOM을 새로운 가상 트리와 일치시키기 위해 필요한 최소한의 변경 목록("diff")을 계산합니다.
2단계: 커밋 단계 (적용)
이제 React가 실제로 브라우저를 건드리는 단계입니다.
- 일괄 업데이트 (Batch Update): React는 계산된 최소한의 변경 목록을 가져와 실제 DOM에 한 번에(Batch) 적용합니다.
- 레이아웃: 브라우저는 상태가 바뀔 때마다 계산하는 것이 아니라, 딱 한 번만 리플로우/리페인트를 수행하면 됩니다.
4. 알고리즘: 재조정 ("Diffing" 로직)
거대한 객체 트리 두 개를 비교하는 것은 이론적으로 비용이 많이 듭니다. 일반적인 트리 비교 알고리즘의 복잡도는 O(n³) 입니다.
- 만약 요소가 1,000개라면, O(n³) 알고리즘은 10억 번의 비교 연산을 수행해야 합니다. 이는 브라우저를 멈추게 할 것입니다.
React는 휴리스틱(Heuristic) O(n) 알고리즘을 사용합니다. 이 알고리즘은 10억 번의 연산을 단 1,000번으로 줄이기 위해 두 가지 주요한 가정을 합니다.
가정 1: 타입이 다르면 다른 트리다
컴포넌트의 루트 요소 타입이 바뀌면, React는 이전 트리를 완전히 파괴하고 처음부터 새로 만듭니다. 이전 것을 고쳐 쓰려고 시간을 낭비하지 않습니다.
- 예시:
<div><Counter /></div>가<span><Counter /></span>로 변경됨. - React의 동작:
div가span으로 바뀐 것을 감지합니다.div를 파괴하고, 그 안의Counter도 파괴(언마운트)한 뒤, 완전히 새로운span과Counter를 마운트합니다.
가정 2: 리스트의 Keys (키)
이것이 바로 React가 리스트에서 "key가 없다"고 경고하는 이유입니다.
Key가 없다면, 리스트 [A, B, C]의 맨 앞 에 Z를 추가하여 [Z, A, B, C]가 될 때, React는 인덱스(순서)별로 비교합니다.
- 인덱스 0이 A에서 Z로 바뀜 (업데이트)
- 인덱스 1이 B에서 A로 바뀜 (업데이트)
- 인덱스 2가 C에서 B로 바뀜 (업데이트)
- 인덱스 3이 새로 생김 (C 추가)
결국 React는 리스트 전체 를 다시 그립니다.
Key가 있다면 (key="unique_id"):
React는 인덱스가 아닌 Key를 기준으로 아이템을 비교합니다.
- React: "Key가 'A'인 아이템은 그대로 있네, 위치만 옮겨."
- React: "Key가 'B'인 아이템도 그대로 있네."
- React: "Key가 'Z'인 아이템은 새로 생겼네."
결과: React는 단순히 Z를 삽입하고 나머지는 이동시킵니다.
5. React Fiber: 현대적인 엔진
구버전 React(Stack Reconciler)에서는 Virtual DOM 비교 작업이 동기적(Synchronous)이고 재귀적이었습니다. 한번 비교를 시작하면 트리가 끝날 때까지 멈출 수 없었습니다. 트리가 거대하면 브라우저가 버벅거렸습니다(프레임 드랍).
React Fiber (v16에서 도입) 는 Virtual DOM 엔진을 완전히 새로 짰습니다.
- 작업 단위 (Unit of Work): Fiber는 Virtual DOM 작업을 "fiber"라는 작은 단위로 쪼갭니다.
- 타임 슬라이싱 (Time Slicing): React는 몇 개의 fiber를 처리한 뒤, 브라우저가 긴급하게 처리할 일(클릭 처리, 애니메이션 등)이 있는지 확인합니다. 있다면 작업을 일시 정지하고 브라우저에게 양보한 뒤, 나중에 다시 작업을 재개합니다.
- 우선순위 지정 (Prioritization): React는 업데이트에 우선순위를 매길 수 있습니다. 타이핑이나 애니메이션은 '높은 우선순위', 백그라운드 데이터 수신은 '낮은 우선순위'로 처리합니다.
6. 요약: 전체 흐름
정리하자면, Virtual DOM 업데이트의 생명주기는 다음과 같습니다.
- 트리거 (Trigger): 상태 변경 발생 (
setState). - 렌더 (Render - Virtual): React가 컴포넌트 함수를 실행하여 새로운 UI 반환값을 얻음.
- 비교 (Diff): React가 새로운 반환값과 이전 값을 비교.
- 재조정 (Reconcile): "이 특정
<div>의className만 바뀌었음"을 식별. - 커밋 (Commit - Real): React가 실제 DOM에 접근하여
document.getElementById('...').className = 'new-class'를 실행.
이러한 추상화 덕분에 개발자는 업데이트 때마다 페이지 전체를 새로 그리는 것처럼 직관적으로 코드를 짤 수 있지만, React는 내부적으로 브라우저에 필요한 최소한의 변경만 적용하여 성능을 보장합니다.
references